Skip to content

feat: add enum declarations and multi-arm enum match expressions#336

Open
stringhandler wants to merge 1 commit into
BlockstreamResearch:masterfrom
stringhandler:feat/multiple-match-arms
Open

feat: add enum declarations and multi-arm enum match expressions#336
stringhandler wants to merge 1 commit into
BlockstreamResearch:masterfrom
stringhandler:feat/multiple-match-arms

Conversation

@stringhandler

Copy link
Copy Markdown
Contributor

Introduces enum with explicit u8 discriminants and N-arm match over enum variants, desugared into jet::eq_8 comparison chains at the AST level. Missing witness values are zero-filled before Simplicity witness population; a post-prune check errors if any zero-filled witness appears on a surviving (non-pruned) branch.

Example

// last_will.simf


enum Action {
    Inherit=1,
    ColdSpend =2,
    HotSpend =3,
}

fn main() {
    match witness::ACTION {
        Action::Inherit => { 
            let inheritor_sig: Signature = witness::INHERITOR_SIG;
            inherit_spend(inheritor_sig)} ,
        Action::ColdSpend => {
            let cold_sig: Signature = witness::COLD_SIG;
            cold_spend(cold_sig) },
        Action::HotSpend => {
            let hot_sig: Signature = witness::HOT_SIG;
            refresh_spend(hot_sig) },
    }
}
// last_will.inherit.wit
{
    "ACTION": {
        "value": "1",
        "type": "u8"
    },
    "INHERITOR_SIG": {
        "value": "0x755201bb62b0a8b8d18fd12fc02951ea3998ba42bfc6664daaf8a0d2298cad43cdc21358c7c82f37654275dc2fea8c858adbe97bac92828b498a5a237004db6f",
        "type": "Signature"
    }
}

Implementation notes

Some things to note that are missing:

1. Enums with structured fields.

I originally wanted to implement enums as something like

enum Path {
   Inherit { signature: Signature}
}

or potentially even with tuples, like Inherit(<inner tuple>). This would certainly look cleaner in the witness file (i.e. as ACTION: { value = "Inherit("0x....")}, however the enum declaration is not present in scope when parsing the file and this makes decoding it difficult.
As a trade off enums must have a u8 encoding, so that they can be parsed from the witness. This explicit declaration should also help when the transaction is examined on the explorer.

Structured enums can possibly be implemented in a future PR.

2. Empty witness filling

By splitting the action/path witness variable into it's own u8, we now have to specify individual witness variables for each of the actions/paths taken in the program. This means that we would now have to specify values for all witnesses, even if they are not called.

I did a deep dive and confirmed that unused witness values are pruned, but calling a program with a witness file or programmatically is still inconvenient. To combat this, I've added a step to fill unspecified witness values with defaults of zero, and then an additional step to make sure that none of the filled values remain when pruned. This is done through satisfy_with_env, so lib callers should not have to do any extra lifting.

3. Enum values in code are out of scope

A limit of the implementation is that using enums in code is not supported. This should be done in another PR.
For example, the following is not supported:

enum Direction {
  Up =1,
  Down = 2
}

fn return_up() -> Direction {
  Direction::Up
}

This is unfortunate but the PR is already quite large and tricky to review.

4. Desugaring into jet::eq_u8 chains

Under the hood, the match statement is desugared into a list of ifs (actually match if you are specific), each with a jet::eq_u8.
While it might have been more efficient to utilize an Either::Left structure, hoping for a tree like evaluation, it was difficult to decode a u8 reliably into Left/Right nodes and allow for skipping enum values (e..g enum ACTION { One = 1, /* Two = 2 --deprecated */, Three = 3 } ). That said, it is not impossible, and I can (reluctantly) create another PR for that implementation if desired.

@stringhandler stringhandler requested a review from delta1 as a code owner May 28, 2026 12:40
@apoelstra

Copy link
Copy Markdown
Contributor

3666ffb needs rebase

@stringhandler stringhandler force-pushed the feat/multiple-match-arms branch 2 times, most recently from ce66af6 to 0eb01e6 Compare May 28, 2026 13:08
@LesterEvSe

Copy link
Copy Markdown
Collaborator

0eb01e6 needs rebase

@stringhandler stringhandler force-pushed the feat/multiple-match-arms branch from 0eb01e6 to 2fa2122 Compare June 29, 2026 13:01
@stringhandler stringhandler marked this pull request as draft June 29, 2026 13:03
@stringhandler stringhandler force-pushed the feat/multiple-match-arms branch 4 times, most recently from e9d43cd to ad3760b Compare July 1, 2026 12:33
@LesterEvSe

Copy link
Copy Markdown
Collaborator

Since we added unstable feature support, I think we need to add the enum keyword to UnstableFeature. Check the doc/unstable-features.md and unstable.rs files for more information

Comment thread src/parse.rs Outdated
/// An enum declaration.
#[derive(Clone, Debug)]
pub struct EnumDeclaration {
file_id: usize,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, file_id is accessible via the Span struct, so we can delete it here. Try to do something similar to the other structs inside the Item struct

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed

Comment thread src/ast.rs
Comment on lines +981 to +1001
pub fn insert_enum(
&mut self,
name: AliasName,
visibility: Visibility,
variants: Arc<[ResolvedEnumVariant]>,
) -> Result<(), Error> {
if self.current_module().enums.contains_key(&name)
|| self.current_module().aliases.contains_key(&name)
{
return Err(Error::RedefinedAlias { name });
}
// An enum is also a `u8` type alias, so its name resolves as a type.
let resolved = self.resolve(&AliasedType::from(UIntType::U8))?;
self.current_module_mut()
.aliases
.insert(name.clone(), (resolved, visibility));
self.current_module_mut()
.enums
.insert(name, EnumBinding::new(variants));
Ok(())
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throughout the PR there's a lot of code where statements are packed together with no blank lines between logically distinct steps, which makes the individual blocks hard to scan.

Could we add blank lines to separate the logical sections? A couple of simple conventions go a long way. For example, a blank line before a return/Ok(()) that closes a block, and a blank line before a comment that introduces a new step.

Suggested change
pub fn insert_enum(
&mut self,
name: AliasName,
visibility: Visibility,
variants: Arc<[ResolvedEnumVariant]>,
) -> Result<(), Error> {
if self.current_module().enums.contains_key(&name)
|| self.current_module().aliases.contains_key(&name)
{
return Err(Error::RedefinedAlias { name });
}
// An enum is also a `u8` type alias, so its name resolves as a type.
let resolved = self.resolve(&AliasedType::from(UIntType::U8))?;
self.current_module_mut()
.aliases
.insert(name.clone(), (resolved, visibility));
self.current_module_mut()
.enums
.insert(name, EnumBinding::new(variants));
Ok(())
}
pub fn insert_enum(
&mut self,
name: AliasName,
visibility: Visibility,
variants: Arc<[ResolvedEnumVariant]>,
) -> Result<(), Error> {
if self.current_module().enums.contains_key(&name)
|| self.current_module().aliases.contains_key(&name)
{
return Err(Error::RedefinedAlias { name });
}
// An enum is also a `u8` type alias, so its name resolves as a type.
let resolved = self.resolve(&AliasedType::from(UIntType::U8))?;
self.current_module_mut()
.aliases
.insert(name.clone(), (resolved, visibility));
self.current_module_mut()
.enums
.insert(name, EnumBinding::new(variants));
Ok(())
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

Comment thread src/ast.rs
Comment on lines +1206 to +1216
parse::Item::EnumDeclaration(decl) => {
let n = decl.variants().len();
if n < 2 {
return Err(Error::Grammar {
msg: format!("enum '{}' must have at least 2 variants", decl.name()),
})
.with_span(decl);
}
let mut sorted: Vec<&parse::EnumVariant> = decl.variants().iter().collect();
sorted.sort_by_key(|v| v.discriminant());
for w in sorted.windows(2) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above; blank lines between logical steps

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added some but this is very subjective, so not sure what you had in mind

Comment thread src/ast.rs
Comment on lines +1566 to +1576
parse::SingleExpressionInner::EnumMatch(enum_match) => {
let arms = enum_match.arms();
let span = *enum_match.span();
if arms.is_empty() {
return Err(Error::Grammar {
msg: "enum match has no arms".to_string(),
})
.with_span(span);
}
let enum_name = match arms[0].pattern() {
MatchPattern::EnumVariant(name, _) => name.clone(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blank lines between logical steps

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added some blank lines but I don't know what you had in mind

Comment thread src/ast.rs
}
let enum_name = match arms[0].pattern() {
MatchPattern::EnumVariant(name, _) => name.clone(),
_ => unreachable!("EnumMatch arms have EnumVariant patterns"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unreachable! will panic the whole process if it's ever hit, and this runs on caller-supplied witnesses/env. The mismatch may be impossible today, but a change in pruned representation could reach it. The function already returns Result, so let's return an Err here instead and fail gracefully

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This unreachable and the one below it seem reasonable to me. I don't like the idea of returning an Error here because that tells me we expect that kind of thing to happen, which it is very unlikely given that we construct the tree.

I considered other patterns but all of them are more complicated than this and I don't see the benefit

Comment thread src/ast.rs
let mut arm_map: HashMap<&Identifier, &parse::Expression> = HashMap::new();
for arm in arms {
let MatchPattern::EnumVariant(arm_enum_name, variant) = arm.pattern() else {
unreachable!("EnumMatch arms have EnumVariant patterns")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment regarding the unreachable! block

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above

Comment thread src/named.rs Outdated
(Inner::Disconnect(cc, _), Inner::Disconnect(cp, _)) => {
check_surviving_witnesses(cc, cp, zero_filled)
}
_ => unreachable!("unexpected structural mismatch between commit and pruned trees"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another unreachable block

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have replaced this with an Err

@stringhandler stringhandler force-pushed the feat/multiple-match-arms branch from ad3760b to 0528aab Compare July 2, 2026 14:54
@stringhandler stringhandler marked this pull request as ready for review July 2, 2026 14:54
Add enum declarations and enum match expressions to SimplicityHL.

Enums are registered as u8 type aliases; each variant has a u8 discriminant
 Enum match expressions desugar to binary bool-match chains that
dispatch on discriminant equality, ensuring invalid discriminants fail via panic
rather than silently executing any arm.
@stringhandler stringhandler force-pushed the feat/multiple-match-arms branch from 0528aab to cfa1116 Compare July 2, 2026 14:58
@stringhandler

Copy link
Copy Markdown
Contributor Author

Added to unstable features

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants